무중단 배포에서 DB 마이그레이션 순서

무중단 배포에서 DB 마이그레이션 순서

한눈에 보기

Rolling이나 Blue/Green 배포에서는 잠시 동안 이전 코드와 새 코드가 같은 DB를 사용한다. 따라서 column rename이나 type 변경을 한 번에 수행하면 어느 한쪽이 깨진다. 먼저 새 schema를 추가하는 expand, 기존 데이터를 작은 batch로 채우고 읽기 경로를 전환하는 migrate, rollback window가 끝난 뒤 이전 schema를 제거하는 contract 순서로 나눈다. 각 단계는 독립 배포와 관측 지점을 가져야 하며, online DDL도 metadata lock·table rebuild·replication lag가 생길 수 있으므로 실제 DB version과 table 크기로 사전 검증한다.

목차

애플리케이션보다 Schema가 먼저 바뀌면 생기는 문제

customers.display_namecustomers.nickname으로 바꾸고 싶다고 하자. 다음 DDL은 깔끔해 보인다.

ALTER TABLE customers
  RENAME COLUMN display_name TO nickname;

하지만 Rolling 배포 중에는 이전 version이 여전히 display_name을 읽는다.

sequenceDiagram
    participant V1 as API v1
    participant DB as Shared DB
    participant V2 as API v2

    V1->>DB: SELECT display_name
    Note over DB: column renamed
    DB--xV1: unknown column
    V2->>DB: SELECT nickname
    DB-->>V2: value

Code를 먼저 배포해도 반대 문제가 생긴다. 새 code가 아직 없는 nickname을 읽는다. Blue/Green에서 traffic을 한 번에 전환해도 기존 long-lived connection, background worker, rollback environment가 DB에 접근할 수 있다.

Schema와 code의 변경 시점을 완벽히 동시에 맞추려는 대신 양쪽 version이 동작하는 호환 기간을 만든다.

무중단의 단위

하나의 migration command가 빠르게 끝나는지가 아니라 배포 전체 동안 모든 실행 가능한 code version이 schema를 이해하는지가 중요하다.

Expand Migrate Contract로 호환 기간 만들기

변경을 세 단계로 나눈다.

flowchart LR
    A[Expand schema]
    B[Compatible code deploy]
    C[Backfill and verify]
    D[Read switch]
    E[Rollback window]
    F[Contract old schema]
    A --> B --> C --> D --> E --> F
단계 수행 이전 Code 동작 Rollback
Expand 새 column/index/table 추가 유지 쉬움
Migrate dual write, backfill, read 전환 호환 유지 설계 필요
Contract 이전 column/constraint 제거 더 이상 보장 안 함 어려움

각 단계는 별도 migration과 배포로 실행한다. 하루에 모두 실행할 수도 있지만 관측과 rollback 가능 시간을 확보하지 않으면 나눈 의미가 없다.

Release 1: 새 schema 추가
Release 2: 양쪽 write 가능한 code
Job:       backfill
Release 3: 새 schema read
Wait:      rollback window
Release 4: 이전 write 제거
Migration: 이전 schema 제거

Column Rename을 Additive 변경으로 바꾸기

실제 rename 대신 새 column을 추가한다. 예시는 MySQL 8.4 InnoDB를 기준으로 하지만 운영 DB version에서 algorithm 지원을 확인해야 한다.

ALTER TABLE customers
  ADD COLUMN nickname VARCHAR(120) NULL,
  ALGORITHM=INSTANT;

새 code는 한동안 두 column을 쓴다.

type CustomerNameUpdate = {
  customerId: string;
  name: string;
};

async function updateCustomerName(
  tx: Transaction,
  command: CustomerNameUpdate,
) {
  await tx.execute(
    `
      UPDATE customers
         SET display_name = ?,
             nickname = ?
       WHERE id = ?
    `,
    [command.name, command.name, command.customerId],
  );
}

두 write가 같은 transaction에 있어야 한쪽만 반영되는 상태를 피할 수 있다. 이 code가 모든 writer에 배포되기 전 backfill을 시작하면 구버전 writer가 display_name만 바꿔 새 column이 다시 뒤처질 수 있다.

Writer inventory를 먼저 만든다.

하나라도 빠지면 dual write 불변식이 깨진다.

Dual Write의 실패 모드 이해하기

애플리케이션에서 서로 다른 두 system에 dual write하면 distributed transaction 문제가 생긴다. 같은 DB transaction 안의 두 column update는 상대적으로 단순하지만 그래도 의미 불일치가 가능하다.

display_name = "Chanho"
nickname     = "chanho"

두 column의 normalization rule이 다르면 byte 비교가 아니라 canonicalized value나 business equivalence를 검증해야 한다.

Database trigger로 동기화할 수도 있다.

CREATE TRIGGER customers_name_sync_before_update
BEFORE UPDATE ON customers
FOR EACH ROW
SET NEW.nickname = COALESCE(NEW.nickname, NEW.display_name);

이 예시는 개념 설명용이며 그대로 쓰면 어느 column이 source인지 모호하다. Trigger는 모든 writer를 덮지만 다음 비용이 있다.

Application dual write, trigger, change data capture 중 하나를 선택하고 source of truth 방향을 명확히 한다.

양방향 동기화

“둘 중 값이 있는 쪽을 복사”하는 규칙은 동시 수정 시 어느 값이 최신인지 결정하지 못한다. 전환 단계별 authoritative column을 정한다.

읽기 전환은 검증 지표와 함께 진행하기

Backfill이 끝났다고 즉시 새 column만 읽지 않는다. 먼저 shadow read로 차이를 관측한다.

async function loadCustomerName(
  db: Database,
  customerId: string,
) {
  const row = await db.queryOne<{
    displayName: string | null;
    nickname: string | null;
  }>(
    `
      SELECT display_name AS displayName,
             nickname
        FROM customers
       WHERE id = ?
    `,
    [customerId],
  );

  if (row.nickname !== row.displayName) {
    metrics.increment("customer_name_mismatch");
  }

  return flags.readNickname
    ? row.nickname ?? row.displayName
    : row.displayName;
}

실제 사용자 값을 log에 남기지 않고 mismatch count와 row category만 기록한다. Feature flag로 일부 instance나 내부 계정부터 read를 전환할 수 있다.

stateDiagram-v2
    [*] --> ReadOld
    ReadOld --> ShadowCompare
    ShadowCompare --> ReadNewWithFallback
    ReadNewWithFallback --> ReadNewOnly

Fallback을 오래 유지하면 migration 누락을 숨긴다. Mismatch가 0이고 모든 writer가 새 column을 채운다는 증거를 얻은 뒤 제거한다.

대용량 Backfill을 작은 Batch로 실행하기

수천만 row를 한 transaction에서 update하면 lock, undo/redo log, replication lag, buffer pool 압박이 커진다.

-- 피해야 할 대규모 일괄 변경
UPDATE customers
   SET nickname = display_name
 WHERE nickname IS NULL;

Primary key cursor로 작은 batch를 처리한다.

type BackfillCheckpoint = {
  lastId: bigint;
};

async function runNameBackfill(
  db: Database,
  checkpoint: BackfillCheckpoint,
) {
  const rows = await db.query<{ id: bigint }>(
    `
      SELECT id
        FROM customers
       WHERE id > ?
         AND nickname IS NULL
       ORDER BY id
       LIMIT 500
    `,
    [checkpoint.lastId],
  );

  if (rows.length === 0) return { done: true };

  const maxId = rows.at(-1)!.id;

  await db.execute(
    `
      UPDATE customers
         SET nickname = display_name
       WHERE id > ?
         AND id <= ?
         AND nickname IS NULL
    `,
    [checkpoint.lastId, maxId],
  );

  return { done: false, lastId: maxId };
}

nickname IS NULL 조건은 이미 새 code가 기록한 값을 덮지 않게 한다. 실제 primary key가 sparse해도 cursor는 진행할 수 있다.

Batch 사이에 짧은 pause를 두고 DB 상태에 따라 속도를 조절한다.

if (replicationLagSeconds > 5 || databaseCpuPercent > 70) {
  await delay(2_000);
} else {
  await delay(100);
}

수치는 가상이다. Fixed sleep보다 lock wait, replication lag, query latency를 기준으로 throttle한다.

동시 Update와 Backfill 충돌 막기

Backfill이 row를 읽은 뒤 사용자가 이름을 바꿀 수 있다.

T1 backfill이 old value A 읽음
T2 API가 display_name과 nickname을 B로 수정
T3 backfill이 nickname을 A로 덮음

앞 예제처럼 DB가 직접 nickname = display_name을 update하고 nickname IS NULL 조건을 같은 statement에서 평가하면 race 범위를 줄인다. Application으로 값을 가져왔다가 나중에 write하지 않는다.

더 복잡한 transform은 optimistic condition을 둔다.

UPDATE customers
   SET nickname = normalize_name(display_name)
 WHERE id = ?
   AND nickname IS NULL
   AND updated_at = ?;

DB에 실제 normalize_name function이 없다면 application batch에서 transaction과 version condition을 사용한다. 충돌 row는 다음 pass에서 재평가한다.

Backfill job은 재시작 가능해야 한다.

NOT NULL과 Constraint를 단계적으로 적용하기

새 column을 처음부터 NOT NULL로 추가하면 기존 row와 구버전 writer 때문에 실패하거나 table operation이 무거울 수 있다.

ALTER TABLE customers
  ADD COLUMN nickname VARCHAR(120) NULL;

순서는 다음과 같다.

  1. Nullable column 추가
  2. 새 writer가 non-null 값 기록
  3. 기존 row backfill
  4. Null count 검증
  5. Database constraint 적용
SELECT COUNT(*) AS missing_count
  FROM customers
 WHERE nickname IS NULL;

MySQL에서 nullable column을 NOT NULL로 바꾸는 동작은 version과 type에 따라 table rebuild를 수반할 수 있다. ALGORITHM=INPLACE, LOCK=NONE을 명시해 지원되지 않으면 조용히 더 무거운 방식으로 fallback하지 않고 실패시키는 전략을 고려한다.

ALTER TABLE customers
  MODIFY COLUMN nickname VARCHAR(120) NOT NULL,
  ALGORITHM=INPLACE,
  LOCK=NONE;

PostgreSQL은 CHECK (...) NOT VALID를 추가한 뒤 기존 row를 별도로 validate하는 패턴을 사용할 수 있다.

ALTER TABLE customers
  ADD CONSTRAINT customers_nickname_present
  CHECK (nickname IS NOT NULL) NOT VALID;

ALTER TABLE customers
  VALIDATE CONSTRAINT customers_nickname_present;

DBMS마다 lock과 최종 SET NOT NULL 최적화가 다르므로 같은 SQL을 이식하지 않는다.

Index 생성과 DDL Lock을 별도로 평가하기

새 read path가 nickname 검색을 사용하면 index가 필요할 수 있다.

CREATE INDEX idx_customers_nickname
    ON customers (nickname);

Index build는 table scan, CPU, I/O, temporary disk, replication traffic을 만든다. “Concurrent DML 허용”과 “성능 영향 없음”은 다르다.

실행 전에 확인한다.

항목 질문
Algorithm metadata-only, inplace, copy 중 무엇인가
Lock read/write가 얼마나 막히는가
Disk 새 index와 temporary copy 공간이 있는가
Replica lag와 apply 비용은 어느 정도인가
Transaction 오래 열린 transaction이 metadata lock을 막는가
Abort 중단 시 rollback 시간과 disk 영향은 무엇인가

MySQL에서는 지원되는 작업에 ALGORITHMLOCK을 명시해 예상보다 위험한 fallback을 막을 수 있다.

ALTER TABLE customers
  ADD INDEX idx_customers_nickname (nickname),
  ALGORITHM=INPLACE,
  LOCK=NONE;

실제 지원 여부는 MySQL version, storage engine, column과 index 종류에 따라 달라진다.

Online DDL도 무중단을 보장하지 않는다

Online DDL은 concurrent DML을 허용할 수 있다는 뜻이지 lock이 전혀 없다는 뜻이 아니다. MySQL online DDL도 준비와 commit 단계에서 metadata lock이 필요할 수 있고, long transaction 때문에 대기할 수 있다.

Long transaction holds metadata dependency
        ↓
ALTER TABLE waits
        ↓
following requests queue behind DDL

Migration 직전에 확인할 것:

SELECT *
  FROM performance_schema.metadata_locks
 WHERE OBJECT_NAME = 'customers';

Query는 운영 version과 권한에 맞게 조정한다. Lock wait timeout과 statement timeout을 두어 traffic을 무기한 멈추지 않게 한다.

Canary table이나 production snapshot 크기의 staging에서 실행 시간을 재지만 production write rate와 buffer pool 상태가 다름을 감안한다.

자동 재시도

무거운 DDL이 timeout 뒤 자동으로 즉시 반복되면 같은 부하를 계속 만든다. Migration runner가 오류를 분류하고 사람의 확인 없이 위험한 DDL을 반복하지 않게 한다.

Type 변경은 새 Column으로 우회하기

VARCHAR(40)BIGINT로 직접 바꾸는 작업은 table copy와 데이터 변환 실패를 유발할 수 있다.

ALTER TABLE orders
  MODIFY COLUMN external_number BIGINT NOT NULL;

새 column을 추가하고 명시적으로 변환한다.

ALTER TABLE orders
  ADD COLUMN external_number_v2 BIGINT NULL;

Application에서 parsing 실패를 domain error로 관측한다.

function parseExternalNumber(value: string): bigint | null {
  if (!/^[0-9]+$/.test(value)) return null;

  const parsed = BigInt(value);
  return parsed <= 9_223_372_036_854_775_807n
    ? parsed
    : null;
}

Invalid row를 임의의 0으로 넣지 않는다. 별도 quarantine과 수동/업무 규칙으로 해결한다. 변환 완료 후 새 column read를 전환하고 old column은 rollback window 뒤 제거한다.

Type 의미가 달라지면 API와 event schema도 함께 versioning해야 한다.

Table 분리와 Foreign Key 변경하기

하나의 table에서 address를 별도 table로 옮기는 변경은 column rename보다 길다.

CREATE TABLE customer_addresses (
  customer_id BIGINT NOT NULL,
  address_type VARCHAR(20) NOT NULL,
  address_text VARCHAR(500) NOT NULL,
  PRIMARY KEY (customer_id, address_type)
);

전환 흐름:

  1. 새 table 생성
  2. 새 write를 old/new에 반영
  3. Historical row backfill
  4. Count, checksum, sample 검증
  5. Read shadow 비교
  6. 새 table read 전환
  7. Old write 중단
  8. Rollback window 뒤 old column 제거

Foreign key 추가는 child와 parent table 양쪽 lock과 validation 비용을 확인한다. Application-level orphan 탐지부터 시작하고 DB constraint 적용을 별도 단계로 둘 수 있다.

SELECT a.customer_id
  FROM customer_addresses AS a
  LEFT JOIN customers AS c
    ON c.id = a.customer_id
 WHERE c.id IS NULL
 LIMIT 100;

결과 전체를 log로 내보내지 않고 count와 제한된 가상화 ID만 운영 접근 범위에서 다룬다.

Rollback 가능 경계를 명시하기

각 단계별 rollback을 문서화한다.

단계 Code rollback Schema rollback
새 nullable column만 추가 가능 굳이 즉시 제거할 필요 없음
Dual write 가능, old read 유지 새 column 보존
New read + fallback flag로 복귀 schema 유지
New-only read 이전 code 호환 확인 schema 유지
Old column drop 이전 code 불가 복원/forward fix 필요

Additive schema는 문제가 생겨도 그대로 두고 code만 이전 경로로 돌리는 편이 안전할 수 있다. 급하게 DROP COLUMN을 실행해 rollback하려다 더 큰 lock을 만들 필요가 없다.

Contract 전에는 다음 증거가 필요하다.

Backup은 즉시 rollback 수단이 아니다

전체 DB를 과거 시점으로 복구하면 migration 이후의 정상 사용자 data도 잃는다.

Migration 실행 주체를 하나로 제한하기

애플리케이션 startup마다 migration을 실행하면 여러 replica가 동시에 DDL을 시도할 수 있다.

Pod 1 startup → migrate
Pod 2 startup → migrate
Pod 3 startup → migrate

Library가 migration lock을 제공하더라도 application readiness와 schema 변경이 결합된다. 배포 pipeline의 단일 job으로 실행하고 성공 뒤 application rollout을 진행하는 편이 명확하다.

jobs:
  expand-schema:
    concurrency:
      group: production-database-migration
      cancel-in-progress: false
    steps:
      - run: run-reviewed-migration --phase expand

  deploy-compatible-code:
    needs: expand-schema
    steps:
      - run: deploy-reviewed-image

명령은 가상이다. Migration credential은 application runtime보다 좁은 시간에만 발급하고 필요한 DDL 권한만 갖게 한다.

Migration table의 “적용됨” 기록만 믿지 말고 실제 schema precondition을 검사한다.

SELECT COLUMN_NAME, IS_NULLABLE, COLUMN_TYPE
  FROM information_schema.columns
 WHERE table_schema = DATABASE()
   AND table_name = 'customers'
   AND column_name = 'nickname';

Schema Version보다 Capability를 확인하기

단일 schema_version = 42 비교는 순차 migration에서는 편하지만 여러 service가 서로 다른 table을 변경하면 필요한 capability를 설명하지 못할 수 있다.

Application startup은 자신에게 필요한 contract를 확인한다.

type SchemaCapability = {
  nicknameColumn: boolean;
  nicknameIndex: boolean;
  minimumEventVersion: number;
};

async function assertCapabilities(
  capability: SchemaCapability,
) {
  if (!capability.nicknameColumn) {
    throw new Error("nickname column is required");
  }
}

실제 요청마다 information schema를 query하지 않고 deploy validation이나 cached startup check로 수행한다. Version number와 capability check를 함께 사용할 수도 있다.

Compatibility matrix를 migration 문서에 둔다.

Application Old schema Expanded schema Contracted schema
v1 가능 가능 불가
v2 dual write 불가할 수 있음 가능 가능
v3 new only 불가 가능 가능

관측할 지표와 중단 기준

Migration 중에는 application과 DB 지표를 함께 본다.

DB: lock wait, active transaction, CPU, IOPS, replica lag
App: query error, p95 latency, pool timeout
Data: remaining rows, mismatch rows, invalid rows
Job: batch duration, rows/sec, retry, checkpoint

가상의 중단 기준:

pauseBackfillWhen:
  replicaLagSeconds: "> 5"
  databaseCpuPercent: "> 75"
  apiP95Increase: "> 20%"
abortMigrationWhen:
  lockWaitSeconds: "> 3"
  writeErrorRate: "> 0.1%"

실제 threshold는 baseline과 SLO로 정한다. 자동 pause와 사람의 resume 절차, alert owner를 함께 둔다.

Backfill 완료율만 보지 않는다.

SELECT
  COUNT(*) AS total_rows,
  SUM(nickname IS NULL) AS missing_rows,
  SUM(nickname <> display_name) AS mismatch_rows
FROM customers;

대형 table 전체 scan 자체가 부담일 수 있다. Index, sampled validation, incremental counter, replica 사용을 workload에 맞춰 선택한다.

전체 시나리오로 검증하기

호환성

부하와 장애

Rollback과 Contract

구현 체크리스트

설계

실행

완료

마무리

무중단 DB migration은 DDL을 빠르게 실행하는 기술보다 신구 code가 공존할 시간을 설계하는 일에 가깝다. Rename과 type 변경을 한 번에 하지 않고 새 schema를 추가하는 expand 단계부터 시작한다.

그다음 모든 writer를 dual write로 바꾸고, 기존 data를 작은 batch로 backfill하며, shadow read와 mismatch 지표로 새 경로를 검증한다. 이 과정에서 online DDL도 metadata lock, I/O, replication lag를 만들 수 있으므로 실제 DB version과 table 조건을 확인한다.

새 read path가 안정된 뒤에도 이전 schema를 바로 지우지 않는다. Rollback window 동안 old code가 동작할 수 있게 보존하고, 모든 reader와 writer가 사라졌다는 증거를 얻은 뒤 contract를 실행한다.

좋은 migration은 “성공” 한 줄보다 각 단계에서 멈추고 되돌릴 수 있는 경계가 명확하다. Schema 호환성, backfill correctness, 운영 부하, rollback 가능성을 각각 측정할 수 있어야 한다.

관련 노트

참고 자료